fix: stop reporting the CLI's own timeout as BACKEND_UNAVAILABLE - #18
Open
StefanoGuerrini wants to merge 1 commit into
Open
fix: stop reporting the CLI's own timeout as BACKEND_UNAVAILABLE#18StefanoGuerrini wants to merge 1 commit into
StefanoGuerrini wants to merge 1 commit into
Conversation
`zenrows fetch` aborted at 90s — exactly the gateway's own request
budget — so any request that used the full budget was a race between our
abort and the API's real error envelope, and the abort usually won. Every
such failure surfaced as:
"code": "BACKEND_UNAVAILABLE",
"message": "Could not reach the Zenrows API.",
"likely_cause": "Network error or timeout: This operation was aborted"
Both claims were false. The API had been reached and was about to answer
with a specific, actionable error, so the operator was sent to check
connectivity instead of reading the answer that already existed.
Two independent defects, both fixed here:
- The client timeout equalled the server budget. The default is now
120s, deliberately above the gateway's 90s ceiling, so the API always
gets to answer for itself. `zenrows fetch` gains `--timeout <ms>`
(milliseconds, mirroring `batch wait --timeout`) to raise it further;
a non-numeric or non-positive value is rejected as INVALID_USAGE
rather than silently falling back to the default.
- Every thrown error mapped to BACKEND_UNAVAILABLE. A client-side
give-up is now REQUEST_TIMEOUT, carrying the elapsed time so the 90s
boundary is visible, and pointing at `--timeout` / dropping
`--wait-for` instead of at the network. BACKEND_UNAVAILABLE is left
for genuine transport failures only, and now also reports elapsed
time.
Our own timer flag, not `err.name === "AbortError"`, is what separates
the two: it cannot be confused with an abort from anywhere else.
The trace-debug skill's failure → action map gains both codes, so an
agent reading a trace is steered the same way.
Refs ACT-1605
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vy8dYdUhLJoS6EHw5jn9mg
StefanoGuerrini
marked this pull request as ready for review
September 2, 2026 13:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
zenrows fetchaborted after 90s, the same value as the API's own request budget. Anyrequest that used the full budget was a race between our client abort and the API's real
error response, and the abort usually won. Every such failure surfaced as:
{ "code": "BACKEND_UNAVAILABLE", "message": "Could not reach the Zenrows API.", "likely_cause": "Network error or timeout: This operation was aborted" }Both claims were wrong. The API had been reached, and it was about to answer with a
specific, actionable error. The user was sent to check connectivity instead of reading
the answer that already existed.
The change
Two independent defects, both fixed:
1. The client timeout equalled the server budget. The default is now
120_000ms,deliberately above the API's 90s ceiling, so the API always gets to answer for itself.
zenrows fetchgains--timeout <ms>to raise it further. Both values are namedconstants in
src/core/http.ts(DEFAULT_TIMEOUT_MS,SERVER_BUDGET_MS), so therelationship between them is stated in one place instead of implied by two literals.
2. Any thrown error mapped to
BACKEND_UNAVAILABLE. A client-side give-up is nowREQUEST_TIMEOUT. It carries the elapsed time, so the 90s boundary is visible, and itpoints at
--timeoutor at dropping--wait-forinstead of at the network.BACKEND_UNAVAILABLEis reserved for genuine transport failures, and now also reportselapsed time.
The same case after the change:
{ "ok": false, "error": { "code": "REQUEST_TIMEOUT", "message": "The CLI stopped waiting after 120s. The Zenrows API did not respond in time.", "likely_cause": "The request was aborted client-side after 120s. The API was reached, so this is not a connectivity problem. The request also passed the API's own 90s budget, so the target is very likely rendering slowly or a wait condition never matched.", "next_action": "Retry with a longer client timeout (`--timeout 180000`). If the target needs a long render, drop `--wait-for` so the request finishes inside the API's budget and the API can return its own error instead.", "suggested_commands": [ "zenrows fetch https://example.com/slow-page --timeout 180000" ] } }The
trace-debugskill's failure-to-action map gains both codes, so an agent reading atrace is steered the same way a human is.
Verification
npm run typecheckandnpm testare clean (192 tests, up from 183). New coverage intests/fetch-timeout.test.ts:--timeoutaccepts milliseconds and defaults when absent--timeoutrejects a non-numeric or non-positive value instead of silently ignoring itrunFetchthreads--timeoutthrough to the HTTP clientREQUEST_TIMEOUT, neverBACKEND_UNAVAILABLEBACKEND_UNAVAILABLE, with the elapsed timeREQUEST_TIMEOUTnames the elapsed time and how to raise the timeoutThe abort test drives the real code path: a
fetchstub that never answers and rejectsonly when the caller's own
AbortControllerfires, which is what undici does in thereported case. Each new assertion was confirmed non-vacuous by mutating the fix and
watching the specific test fail (dropping the
timedOutbranch fails 3, setting thedefault back to 90s fails 1).
Scope notes
--timeoutonzenrows extract.extractgoes through the samerunFetch, soit picks up the 120s default and the
REQUEST_TIMEOUTmapping automatically. Theplumbing (
FetchOptions.timeoutMs) is in place if the flag is wanted later.src/core/browser-api.ts,src/core/batch-api.ts,src/core/usage.tsandsrc/core/agent-account.tsmap theirown abort to
BACKEND_UNAVAILABLEin the same way. This PR stays onsrc/core/http.ts, andrequestTimeout()is exported so it can be reused verbatim.err.name === "AbortError",so an abort originating elsewhere cannot be misreported and there is no cross-runtime
assumption about the error's
name.